"use client" import * as React from 'react' import { useRouter, useSearchParams } from 'next/navigation' import { Page, PageBody } from '@open-mercato/ui/backend/Page' import { Button } from '@open-mercato/ui/primitives/button' import { flash } from '@open-mercato/ui/backend/FlashMessages' import { apiCallOrThrow, readApiResultOrThrow, withScopedApiRequestHeaders, } from '@open-mercato/ui/backend/utils/apiCall' import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock' import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts' import { LoadingMessage, ErrorMessage, RecordNotFoundState } from '@open-mercato/ui/backend/detail' import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation' import { useT } from '@open-mercato/shared/lib/i18n/context' import { ProposalCard, type DisposeKind } from '../../../components/ProposalCard' import { parseQueueState, serializeQueueState } from '../hooks' import { normalizeProposalEnvelope, rankProposalOptions } from '../../../data/proposalEnvelope' import { AgentIoDrawer } from '../../../components/AgentIoDrawer' import { FactsGrid, ReasoningList } from '../../../components/ProposalFacts' import { OPTIONAL_REQUEST_INIT } from '../../../components/optionalRequest' import { mapAgent, mapProposal, mapRun, type AgentFactView, type ProposalView, type RunView, } from '../../../components/types' type ProposalsResponse = { items?: Array> } type RunsResponse = { items?: Array> } type PageState = 'loading' | 'notFound' | 'error' | 'ready' /** * The option a freshly loaded proposal starts on: the one it was disposed with, * else the sole option when there is no choice to make, else nothing — a genuine * choice is the operator's to make, and preselecting the leader is the * placeholder Phase 4 removes. */ function resolveInitialOption(proposal: ProposalView): string | null { if (proposal.selectedOptionId) return proposal.selectedOptionId const options = rankProposalOptions(normalizeProposalEnvelope(proposal.payload).options) return options.length === 1 ? options[0].id : null } export default function AgentProposalDetailPage({ params }: { params?: { proposalId?: string } }) { const t = useT() const router = useRouter() const searchParams = useSearchParams() const proposalId = params?.proposalId ?? '' // Queue params forwarded by the list page (spec 4 Phase 5): the Back button // and the post-dispose redirect rebuild the exact queue the operator left. // Parse→serialize normalizes the forwarded string and drops junk params. const caseloadHref = React.useMemo(() => { const queueQuery = serializeQueueState(parseQueueState(searchParams)) return queueQuery ? `/backend/caseload?${queueQuery}` : '/backend/caseload' }, [searchParams]) const [state, setState] = React.useState('loading') const [proposal, setProposal] = React.useState(null) const [run, setRun] = React.useState(null) const [agentLabel, setAgentLabel] = React.useState(null) const [agentFacts, setAgentFacts] = React.useState(undefined) const [selectedOptionId, setSelectedOptionId] = React.useState(null) const [drawerOpen, setDrawerOpen] = React.useState(false) const [busy, setBusy] = React.useState(false) const [reloadToken, setReloadToken] = React.useState(0) const { runMutation, retryLastMutation } = useGuardedMutation<{ proposalId: string data: ProposalView | null retryLastMutation: () => Promise }>({ contextId: `agent_orchestrator.proposal:${proposalId}`, blockedMessage: t('agent_orchestrator.proposal.flash.blocked'), }) React.useEffect(() => { let cancelled = false async function load() { setState('loading') try { const data = await readApiResultOrThrow( `/api/agent_orchestrator/proposals?id=${encodeURIComponent(proposalId)}`, ) if (cancelled) return const items = Array.isArray(data.items) ? data.items : [] const mapped = items[0] ? mapProposal(items[0] as Record) : null if (!mapped) { setState('notFound') return } setProposal(mapped) // Which option the verdict runs. A disposed proposal replays the option it // ran; a pending one preselects ONLY when there is a single option — with // real alternatives the choice belongs to the human, so nothing is // preselected and approve stays disabled until one is picked. setSelectedOptionId(resolveInitialOption(mapped)) // Best-effort resolve of the agent's display label. try { const agentsData = await readApiResultOrThrow<{ items?: Array> }>( '/api/agent_orchestrator/agents', OPTIONAL_REQUEST_INIT, ) if (!cancelled) { const found = (Array.isArray(agentsData.items) ? agentsData.items : []) .map((item) => mapAgent(item as Record)) .find((agent) => agent?.id === mapped.agentId) setAgentLabel(found?.label ?? null) setAgentFacts(found?.facts) } } catch { if (!cancelled) { setAgentLabel(null) setAgentFacts(undefined) } } // Best-effort load of the originating run for the I/O drawer. try { const runData = await readApiResultOrThrow( `/api/agent_orchestrator/runs?id=${encodeURIComponent(mapped.runId)}`, OPTIONAL_REQUEST_INIT, ) if (!cancelled) { const runItems = Array.isArray(runData.items) ? runData.items : [] setRun(runItems[0] ? mapRun(runItems[0] as Record) : null) } } catch { if (!cancelled) setRun(null) } if (!cancelled) setState('ready') } catch { if (!cancelled) setState('error') } } if (proposalId) load() else setState('notFound') return () => { cancelled = true } }, [proposalId, reloadToken]) const dispose = React.useCallback( async (disposition: DisposeKind, payload?: unknown, reason?: string) => { if (!proposal) return setBusy(true) try { await runMutation({ operation: () => withScopedApiRequestHeaders( buildOptimisticLockHeader(proposal.updatedAt), () => apiCallOrThrow( `/api/agent_orchestrator/proposals/${encodeURIComponent(proposalId)}/dispose`, { method: 'POST', headers: { 'content-type': 'application/json' }, // The verdict names which alternative it runs; a reject runs // none and must NOT carry an option id. body: JSON.stringify({ disposition, payload, reason, selectedOptionId: disposition === 'rejected' ? undefined : selectedOptionId, }), }, ), ), context: { proposalId, data: proposal, retryLastMutation }, mutationPayload: { disposition }, }) const successKey = disposition === 'approved' ? 'agent_orchestrator.proposal.flash.approved' : disposition === 'edited' ? 'agent_orchestrator.proposal.flash.edited' : 'agent_orchestrator.proposal.flash.rejected' flash(t(successKey), 'success') router.push(caseloadHref) } catch (err) { // useGuardedMutation already surfaces 409 conflicts on the shared bar; // call again defensively in case a future caller suppresses it, then // fall back to a flash for non-conflict errors. if (!surfaceRecordConflict(err, t)) { const message = err instanceof Error ? err.message : t('agent_orchestrator.proposal.flash.error') flash(message, 'error') } } finally { setBusy(false) } }, [proposal, proposalId, selectedOptionId, retryLastMutation, router, runMutation, caseloadHref, t], ) if (state === 'loading') { return ( ) } if (state === 'notFound') { return ( ) } if (state === 'error' || !proposal) { return ( ) } return (

{t('agent_orchestrator.proposal.title')}

{proposal.runId ? ( ) : null} {proposal.workflowInstanceId ? ( ) : null}
setDrawerOpen(true)} selectedOptionId={selectedOptionId} onSelectOption={setSelectedOptionId} actions={{ canDispose: true, busy, onApprove: () => dispose('approved'), onEdit: (payload, reason) => dispose('edited', payload, reason), onReject: (reason) => dispose('rejected', undefined, reason), }} />
) }